Skip to content

feat(attestation)!: pin verification to archived collateral with VerifyMode - #93

Open
samlaf wants to merge 2 commits into
flashbots:mainfrom
SeismicSystems:sei-208/pr2-verify-mode
Open

feat(attestation)!: pin verification to archived collateral with VerifyMode#93
samlaf wants to merge 2 commits into
flashbots:mainfrom
SeismicSystems:sei-208/pr2-verify-mode

Conversation

@samlaf

@samlaf samlaf commented Sep 1, 2026

Copy link
Copy Markdown
Contributor

Fixes the second half of #84. First half was #85.

TLDR: we archive the evidence from our genesis machines together with the
EndorsementSnapshot that #85 made verification report, and later we need to
re-verify that evidence and get the same answer. Today nothing exposes that: the
public entry points always fetch collateral and always check freshness at the
wall clock, so about a month after collection a perfectly good archive stops
verifying. This adds an archived verification method that takes the snapshot
back.

Addressing review

The first commit did this as a VerifyMode enum threaded through
verify_attestation. Per the review, it is now a separate method and the live
signatures are exactly as on main. The second commit is that rewrite; the diff
against main is what to review.

The other review comment, about bailing on a missing PCCS in the sync path, was
resolved by #88 making the PCCS mandatory.

API

AttestationVerifier
verify_attestation(msg, input) unchanged
verify_attestation_sync(msg, input) unchanged
verify_attestation_archived(msg, input, &EndorsementSnapshot) new

dcap
verify_dcap_attestation(q, input, pccs) unchanged
verify_dcap_attestation_sync(q, input, pccs) unchanged
verify_dcap_attestation_archived(q, input, &snapshot) new
verify_dcap_attestation_with_given_timestamp(...) removed
verify_dcap_attestation_with_timestamp_sync(...) removed

azure
verify_azure_attestation(a, input, pccs, override) unchanged
verify_azure_attestation_sync(a, input, pccs, override) unchanged
verify_azure_attestation_archived(a, input, &snapshot, override) new

DcapVerificationError::ArchivedWithoutDcapCollateral new

What archived means

The three entry points on each platform are one per situation a relying party
is in. The async one is for a live handshake with a runtime. The sync one is
for a live handshake inside a callback that cannot await, such as a rustls
verifier, and can only read the PCCS cache. The archived one is for re-checking
evidence long after the fact, against the snapshot its original verification
reported: it fetches nothing and evaluates every freshness check at the
snapshot's instant rather than now. On Azure that includes the AK certificate
chain, so one instant sits behind both legs.

It takes the EndorsementSnapshot, not bare collateral. The removed
_with_given_timestamp variants took collateral and an instant as two
arguments, so a caller could pair a pinned bundle with the wrong instant, or a
live fetch with a pinned instant, and get a verdict that reproduces nothing.
With the snapshot the bundle and the instant it was held to travel together. A
snapshot with no DCAP bundle is refused with ArchivedWithoutDcapCollateral
rather than completed by a fetch.

This is deliberately not the case where collateral arrives out of band and is
checked at the wall clock, as #65 proposes. That is a live verification and
would be its own method; the snapshot's instant is what makes this one a
replay.

GCP

This is a behaviour change from the version reviewed, which kept both GCP
checks live in archived mode.

The provenance lookup against Google's PPID registry is skipped on replay.
The registry is unsigned and mutable, so a replay could only learn what it says
today, and if an entry disappeared a sound archive would stop verifying, which
is the failure the archive exists to prevent. The original verification already
consulted it.

Firmware for a portable-image policy is still fetched on a cache miss. It is
signed by Google and content-addressed by the quote's MRTD, so the fetch cannot
change the verdict. Skipping it would make every GCP archive with an image-hash
policy fail in a fresh process, which is a silent trap.

So the contract is "fetches nothing whose answer can change over time" rather
than "fetches nothing". Recording the provenance outcome and the firmware in
the snapshot would remove both caveats and is left for a later change, as is
reporting the compiled-in trust anchors from the #84 discussion.

Internals

The five timestamp-flavoured DCAP and Azure functions collapse into three
crate-private bodies, verify_quote, verify_quote_sync and
verify_quote_archived, that each know where their collateral and instant come
from and reach one collateral-in-hand verifier. That verifier is parameterised
on the dcap-qvl QuoteVerifier, so mock builds replay against the mock root
the same way they verify live.

The Azure verifier splits its prepared evidence into the TD quote and the vTPM
leg, so its three entry points share everything but the DCAP call, and the
finish function assembles the VerifiedAttestation and reads the instant from
the DCAP leg in one place.

On AttestationVerifier, the sync and archived methods share one body that
differs only in where the DCAP leg gets its endorsements and whether the
provenance lookup runs. The async path is untouched.

Tests

The fixture tests that replayed real captures through the removed variants now
go through the archived path. Two new verifier-level tests replay a live mock
verdict through verify_attestation_archived and check that measurements,
matched policy and snapshot all agree, and check that a GCP-typed replay does
not consult the registry (a mock PPID is not in it, so the lookup would fail
closed if it ran).

BREAKING CHANGE: verify_dcap_attestation_with_given_timestamp and
verify_dcap_attestation_with_timestamp_sync are removed, replaced by
verify_dcap_attestation_archived. Every other public signature is as on main.

@ameba23 ameba23 left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⭐ Generally looks good but i would prefer to have passing in collateral as being a special method for a special purpose.

Comment thread crates/attestation/src/lib.rs Outdated
// every call site for a value that is never stored.
#[allow(clippy::large_enum_variant)]
#[derive(Clone, Debug, PartialEq, Eq)]
pub enum VerifyMode {

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

After discussing this with others at our meeting, i think the archived mode should be a separate method rather than passing an enum to verify_attestation.

That is verifiy_attestation stays as is, and add a separate method call something like verify_attestation_ator verify_attestation_with_collateral.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done in the latest commit. Prob easier to just compare diff against main however, since it's now pretty drastically different from the previous version with the enum. LMKWYT.

Comment thread crates/attestation/src/lib.rs Outdated

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

With 'archive mode' (given collateral) we don't need to bail here if no pccs.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is no longer an issue since #88 made pccs mandatory

…fyMode

Closes flashbots#84

Second of two changes for that issue, on top of flashbots#85.

Reporting the endorsements a verification consumed is half of provenance.
The other half is running the same verification again later, against
that snapshot, and getting the same answer. Nothing exposed that: the
public entry points always fetched and always read the wall clock, and
the only way to supply a bundle was through variants that also took a
bare timestamp.

API before and after
--------------------

    AttestationVerifier
      verify_attestation(msg, input)                -> verify_attestation(msg, input, mode)
      verify_attestation_sync(msg, input)           -> verify_attestation_sync(msg, input, mode)

    dcap
      verify_dcap_attestation(q, input, pccs)       -> verify_dcap_attestation(q, input, mode, pccs)
      verify_dcap_attestation_sync(q, input, pccs)  -> verify_dcap_attestation_sync(q, input, mode, pccs)
      verify_dcap_attestation_with_given_timestamp(
          q, input, pccs, Option<collateral>, now,
          override_azure_outdated_tcb)              -> (removed)
      verify_dcap_attestation_with_timestamp_sync(
          q, input, pccs, Option<collateral>, now,
          override_azure_outdated_tcb)              -> (removed)

    azure
      verify_azure_attestation(a, input, pccs, override)
                                                    -> verify_azure_attestation(a, input, mode, pccs, override)
      verify_azure_attestation_sync(a, input, pccs, override)
                                                    -> verify_azure_attestation_sync(a, input, mode, pccs, override)

    new
      enum VerifyMode { Live, Archived(EndorsementSnapshot) }
      DcapVerificationError::ArchivedWithoutDcapCollateral

Why this shape
--------------

One input instead of two. The removed variants took the collateral and
the instant as separate arguments, so a caller could pair a pinned bundle
with the wrong instant, or a live fetch with a pinned instant, and get a
verdict that reproduces nothing. VerifyMode::Archived takes the
EndorsementSnapshot that flashbots#85 hands back, so the bundle and the instant it
was held to travel together and the mistake has no spelling. The mixed
case is refused too: an archived snapshot with no bundle for the DCAP leg
fails with ArchivedWithoutDcapCollateral rather than being completed by a
fetch.

The mode reaches the verifier. The measurement-policy check lives on
AttestationVerifier, and a relying party re-checking archived evidence
needs both it and the pinned instant. The removed variants sat below the
verifier, so that combination did not exist. The mode is a parameter of
the call rather than the builder because it is a fact about one
verification, not about the verifier: the same instance serves a live
handshake and an archive replay.

One instant for both Azure legs. The DCAP leg reports the instant it
evaluated at, and the vTPM AK chain is checked at that same instant, in
either mode. The wall clock is read in exactly one place.

The Azure TCB override leaves the public surface. It rode along on the
removed variants only because the Azure verifier and the fixture tests
shared them. Both now call the crate-private body that the two public
entry points wrap, so the override is an argument of the Azure leg and
nothing else. Only Azure has a reason to relax TCB checks.

Live is behaviour-preserving. Every existing caller passes
VerifyMode::Live and gets what it got before: collateral from the PCCS or
Intel, freshness at the wall clock. The two in-tree callers, attested-tls
and attestation-provider-server, needed only that argument.

GCP checks and Archived mode
----------------------------

The GCP host provenance check from flashbots#54 stays live
in either mode, as does the firmware fetch for the quote's MRTD. Neither
rests on signed material a replay could re-verify: the provenance
document is an unsigned JSON object whose trust is the TLS connection to
Google's bucket, so archiving it would not make a replay stronger. The
docs on VerifyMode::Archived and verify_attestation state the carve-out.
Whether Archived should skip the provenance lookup instead is left open.

BREAKING CHANGE: verify_attestation and verify_attestation_sync take a
VerifyMode; the DCAP and Azure entry points take mode before pccs; the
*_with_given_timestamp variants are gone, replaced by
VerifyMode::Archived, which fails with
DcapVerificationError::ArchivedWithoutDcapCollateral when its snapshot
carries no DCAP bundle.
@samlaf
samlaf force-pushed the sei-208/pr2-verify-mode branch from d970661 to 521fa50 Compare September 9, 2026 15:11
…VerifyMode

Review on flashbots#93 asked for the archived case to be its
own method rather than an enum threaded through verify_attestation. This
does that, and drops the mode everywhere it had reached.

API
---

    AttestationVerifier
      verify_attestation(msg, input, mode)        -> verify_attestation(msg, input)        as on main
      verify_attestation_sync(msg, input, mode)   -> verify_attestation_sync(msg, input)   as on main
                                                     verify_attestation_archived(msg, input, &EndorsementSnapshot)  new

    dcap
      verify_dcap_attestation(q, input, mode, pccs)      -> verify_dcap_attestation(q, input, pccs)      as on main
      verify_dcap_attestation_sync(q, input, mode, pccs) -> verify_dcap_attestation_sync(q, input, pccs) as on main
                                                            verify_dcap_attestation_archived(q, input, &snapshot)  new

    azure
      verify_azure_attestation(a, input, mode, pccs, override)      -> (…, pccs, override)  as on main
      verify_azure_attestation_sync(a, input, mode, pccs, override) -> (…, pccs, override)  as on main
                                                                       verify_azure_attestation_archived(a, input, &snapshot, override)  new

    enum VerifyMode                                     removed
    DcapVerificationError::ArchivedWithoutDcapCollateral  kept

Why a method
------------

Live verification and archive replay are different operations a relying
party does in different places, never both per call, so a parameter that
selects between them buys nothing and costs every live caller a
VerifyMode::Live. Keeping them apart leaves the live signatures exactly
as main has them and lets the replay state its own contract.

The snapshot stays the input. The archived methods take the
EndorsementSnapshot that flashbots#85 hands back, not bare collateral, so the
bundle and the instant it was held to travel together. A snapshot with no
DCAP bundle is still refused with ArchivedWithoutDcapCollateral rather
than completed by a fetch. This is deliberately not the case where
collateral arrives out of band and is checked at the wall clock, as
flashbots#65 proposes; that would be a live method.

Synchronous. A replay fetches nothing time-dependent, so it needs no
runtime and has no async twin. The Azure AK chain is checked at the
snapshot's instant too, so one instant sits behind every freshness check.

GCP
---

The previous version kept the provenance lookup live in archived mode
and left "should it?" open. It is now skipped on replay. The registry
is unsigned and mutable, so a replay could only learn what it says
today, and if an entry disappeared a sound archive would stop verifying,
which is the failure the archive exists to prevent. The original
verification already consulted it.

Firmware for a portable-image policy is still fetched on a cache miss:
it is signed by Google and content-addressed by the quote's MRTD, so the
fetch cannot change the verdict. Recording the provenance outcome and
the firmware in the snapshot would remove both caveats and is left for a
later change.

Internals
---------

resolve_mode and the fallback-collateral plumbing go. The three
crate-private bodies verify_quote, verify_quote_sync and
verify_quote_archived each know their own source and reach one
collateral-in-hand verifier, still parameterised on the dcap-qvl
QuoteVerifier so mock builds replay against the mock root. The mock live
entry points with a passthrough PCCS reuse the archived body with the
embedded bundle at the wall clock, which is what they always did in
effect.

The Azure verifier splits its prepared evidence into the TD quote and
the vTPM leg, so its three entry points share everything but the DCAP
call, and the finish function now assembles the VerifiedAttestation and
reads the instant from the DCAP leg in one place. The two verifier
methods that need no runtime share one body that differs only in where
the DCAP leg gets its endorsements and whether provenance runs.

Module docs on dcap and azure::verify say which situation each entry
point is for before saying how they differ. The README gains a paragraph
on the snapshot and the archived method.

Tests: the fixture replays move from the removed variants onto the
archived path; VerifyMode::Live disappears from every live call; two new
verifier-level tests replay a live mock verdict and check that a GCP
replay does not consult the registry. attested-tls and
attestation-provider-server are back to their main versions.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
@samlaf

samlaf commented Sep 9, 2026

Copy link
Copy Markdown
Contributor Author

Rebased on top of the new main and fixed the issues. Then created a new commit with the requested API change.

@samlaf
samlaf requested a review from ameba23 September 9, 2026 17:23
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants